//
// TorrentCreator.cs
//
// Authors:
//   Gregor Burger burger.gregor@gmail.com
//   Alan McGovern alan.mcgovern@gmail.com
//
// Copyright (C) 2006-2007 Gregor Burger and Alan McGovern
//
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
// 
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
// 
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
//



using System;
using System.IO;
using System.Text;
using System.Diagnostics;
using System.Collections;
using System.Collections.Generic;
using System.Security.Cryptography;
using MonoTorrent.BEncoding;
using System.Threading;
using MonoTorrent.Client;
using MonoTorrent.Client.PieceWriters;


namespace MonoTorrent.Common
{
    public class TorrentCreator
    {
        #region Events

        /// <summary>
        /// This event indicates the progress of the torrent creation and is fired every time a piece is hashed
        /// </summary>
        public event EventHandler<TorrentCreatorEventArgs> Hashed;

        #endregion


        #region Private Fields

        private List<List<string>> announces;                     // The list of announce urls
        private bool ignoreHiddenFiles;                             // True if you want to ignore hidden files when making the torrent
        private string path = "";                                        // The path from which the torrent will be created (can be file or directory)
        private TorrentCreatorAsyncResult result;                   // The IAsyncResult generated by creating the torrent
        private bool storeMd5;                                      // True if an MD5 hash of each file should be included
        private BEncodedDictionary dict;                         // The BencodedDictionary which contains the data to be written to the .torrent file
        private SHA1 hasher = HashAlgoFactory.Create<SHA1>();

        #endregion Private Fields


        #region Properties

        public List<List<string>> Announces
        {
            get { return this.announces; }
        }

        public string Comment
        {
            get
            {
                BEncodedValue val = Get(this.dict, new BEncodedString("comment"));
                return val == null ? string.Empty : val.ToString();
            }
            set { Set(this.dict, "comment", new BEncodedString(value)); }
        }

        public string CreatedBy
        {
            get
            {
                BEncodedValue val = Get(this.dict, new BEncodedString("created by"));
                return val == null ? string.Empty : val.ToString();
            }
            set { Set(this.dict, "created by", new BEncodedString(value)); }
        }

        public string Encoding
        {
            get { return Get(this.dict, (BEncodedString)"encoding").ToString(); }
            private set { Set(this.dict, "encoding", (BEncodedString)value); }
        }

        internal SHA1 Hasher
        {
            get { return hasher; }
            set { hasher = value; }
        }

        public bool IgnoreHiddenFiles
        {
            get { return ignoreHiddenFiles; }
            set { ignoreHiddenFiles = value; }
        }

        /// <summary>
        /// The path from which the torrent can be created. This can be either
        /// a file or a folder containing the files to hash.
        /// </summary>
        public string Path
        {
            get { return path  ?? ""; }
            set { path = value ?? ""; }
        }

        ///<summary>
        /// The length of each piece in bytes (range 16384 bytes -> 4MB)
        ///</summary>
        public long PieceLength
        {
            get
            {
                BEncodedValue val = Get((BEncodedDictionary)this.dict["info"], new BEncodedString("piece length"));
                return val == null ? -1 : ((BEncodedNumber)val).Number;
            }
            set { Set((BEncodedDictionary)this.dict["info"], "piece length", new BEncodedNumber(value)); }
        }

        ///<summary>
        /// A private torrent can only accept peers from the tracker and will not share peer data
        /// through DHT
        ///</summary>
        public bool Private
        {
            get
            {
                BEncodedValue val = Get((BEncodedDictionary)this.dict["info"], new BEncodedString("private"));
                return val == null ? false : ((BEncodedNumber)val).Number == 1;
            }
            set { Set((BEncodedDictionary)this.dict["info"], "private", new BEncodedNumber(value ? 1 : 0)); }
        }

        public string Publisher
        {
            get
            {
                BEncodedValue val = Get((BEncodedDictionary)this.dict["info"], new BEncodedString("publisher"));
                return val == null ? string.Empty : val.ToString();
            }
            set { Set((BEncodedDictionary)this.dict["info"], "publisher", new BEncodedString(value)); }
        }

        public string PublisherUrl
        {
            get
            {
                BEncodedValue val = Get((BEncodedDictionary)this.dict["info"], new BEncodedString("publisher-url"));
                return val == null ? string.Empty : val.ToString();
            }
            set { Set((BEncodedDictionary)this.dict["info"], "publisher-url", new BEncodedString(value)); }
        }

        public bool StoreMD5
        {
            get { return storeMd5; }
            set { storeMd5 = value; }
        }

        #endregion Properties


        #region Constructors

        public TorrentCreator()
        {
            BEncodedDictionary info = new BEncodedDictionary();
            this.announces = new List<List<string>>();
            this.ignoreHiddenFiles = true;
            this.dict = new BEncodedDictionary();
            this.dict.Add("info", info);

            // Add in initial values for some of the torrent attributes
            PieceLength = 256 * 1024;   // 256kB default piece size
            this.Encoding = "UTF-8";
        }

        #endregion Constructors


        #region Methods

        /// <summary>
        /// Adds a custom value to the main bencoded dictionary
        /// </summary>        
        public void AddCustom(BEncodedString key, BEncodedValue value)
        {
            this.dict.Add(key, value);
        }

        public TorrentCreatorAsyncResult BeginCreate(object asyncState, AsyncCallback callback)
        {
            if (result != null)
                throw new TorrentException("You must call EndCreate before calling BeginCreate again");

            result = new TorrentCreatorAsyncResult(callback, asyncState);

            // Start the processing in a seperate thread, a user thread.
            new Thread(new ThreadStart(AsyncCreate)).Start();
            return result;
        }

        private void LoadFiles(string path, List<TorrentFile> files)
        {
            if (Directory.Exists(path))
            {
                foreach (string subdir in System.IO.Directory.GetFileSystemEntries(path))
                    LoadFiles(subdir, files);
            }
            else if (File.Exists(path))
            {
                string filePath;
                if (path == Path)
                {
                    filePath = System.IO.Path.GetFileName (path);
                }
                else
                {
                    filePath = path.Substring(Path.Length);
                    if (filePath[0] == System.IO.Path.DirectorySeparatorChar)
                        filePath = filePath.Substring(1);
                }
                FileInfo info = new FileInfo(path);
                if (!((info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden && IgnoreHiddenFiles))
                    files.Add(new TorrentFile(filePath, info.Length, 0, 0, null, null, null));
            }
        }

        /// <summary>
        /// Creates a Torrent and returns it in it's dictionary form
        /// </summary>
        /// <returns></returns>
        public BEncodedDictionary Create()
        {
            return Create (new DiskWriter());
        }

        internal BEncodedDictionary Create(PieceWriter writer)
        {
            if (!Directory.Exists(Path) && !File.Exists(Path))
                throw new ArgumentException("no such file or directory", Path);

            List<TorrentFile> files = new List<TorrentFile>();
            LoadFiles(Path, files);

            if(files.Count == 0)
                throw new TorrentException ("There were no files in the specified directory");

            string[] parts = path.Split(new char[] { System.IO.Path.DirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries);
            string name = File.Exists (Path) ? System.IO.Path.GetFileName(path) : parts[parts.Length - 1];
            return Create(files.ToArray(), writer, name);
        }

        ///<summary>
        /// Creates a Torrent and writes it to disk in the specified location
        ///<summary>
        ///<param name="storagePath">The path (including filename) where the new Torrent will be written to</param>
        public void Create(string path)
        {
            Check.PathNotEmpty(path);

            using (FileStream stream = new FileStream(path, FileMode.Create))
                Create(stream);
        }

        /// <summary>
        /// Generates a Torrent and writes it to the supplied stream
        /// </summary>
        /// <param name="stream">The stream to write the torrent to</param>
        public void Create(Stream stream)
        {
            Check.Stream(stream);

            BEncodedDictionary torrentDict = Create();

            byte[] data = torrentDict.Encode();
            stream.Write(data, 0, data.Length);
        }

        internal BEncodedDictionary Create(TorrentFile[] files, PieceWriter writer, string name)
        {
            // Clone the base dictionary and fill the remaining data into the clone
            BEncodedDictionary torrentDict = BEncodedDictionary.Decode<BEncodedDictionary>(dict.Encode());
            Array.Sort<TorrentFile>(files, delegate(TorrentFile a, TorrentFile b) {
                return String.CompareOrdinal(a.Path, b.Path);
            });

            if (Directory.Exists (Path))
            {
                Logger.Log(null, "Creating multifile torrent from: {0}", Path);
                CreateMultiFileTorrent(torrentDict, files, writer, name);
            }
            else
            {
                Logger.Log(null, "Creating singlefile torrent from: {0}", Path);
                CreateSingleFileTorrent(torrentDict, files, writer, name);
            }

            return torrentDict;
        }

        /// <summary>
        /// Ends the asynchronous torrent creation and returns the torrent in it's dictionary form
        /// </summary>
        /// <param name="result"></param>
        /// <returns></returns>
        public BEncodedDictionary EndCreate(IAsyncResult result)
        {
            Check.Result(result);

            if (result != this.result)
                throw new ArgumentException("The supplied async result does not correspond to currently active async result");

            try
            {
                if(!result.IsCompleted)
                    result.AsyncWaitHandle.WaitOne();

                if (this.result.SavedException != null)
                    throw this.result.SavedException;

                return this.result.Aborted ? null : this.result.Dictionary;
            }
            finally
            {
                this.result = null;
            }
        }

        /// <summary>
        /// Ends the asynchronous torrent creation and writes the torrent to the specified path
        /// </summary>
        /// <param name="result"></param>
        /// <returns></returns>
        public bool EndCreate(IAsyncResult result, string path)
        {
            if (string.IsNullOrEmpty(path))
                throw new ArgumentNullException("path");

            using (FileStream s = File.OpenWrite(path))
                return EndCreate(result, s);
        }

        /// <summary>
        /// Ends the asynchronous torrent creation and writes the torrent to the specified stream
        /// </summary>
        /// <param name="result"></param>
        /// <returns></returns>
        public bool EndCreate(IAsyncResult result, Stream stream)
        {
            if (stream == null)
                throw new ArgumentNullException("stream");

            BEncodedDictionary data = EndCreate(result);
            byte[] buffer = data.Encode();
            if (data != null)
                stream.Write(buffer, 0, buffer.Length);

            return data != null;
        }

        ///<summary>
        /// Calculates the approximate size of the final .torrent in bytes
        ///</summary>
        public long GetSize()
        {
            MonoTorrentCollection<string> paths = new MonoTorrentCollection<string>();

            if (Directory.Exists(this.path))
                GetAllFilePaths(this.path, paths);
            else if (File.Exists(this.path))
                paths.Add(path);
            else
                return 64 * 1024;

            long size = 0;
            for (int i = 0; i < paths.Count; i++)
                size += new FileInfo(paths[i]).Length;

            return size;
        }

        public int RecommendedPieceSize()
        {
            long totalSize = GetSize();
            
            // Check all piece sizes that are multiples of 32kB 
            for (int i = 32768; i < 4 * 1024 * 1024; i *= 2)
            {
                int pieces = (int)(totalSize / i) + 1;
                if ((pieces * 20) < (60 * 1024))
                    return i;
            }

            // If we get here, we're hashing a massive file, so lets limit
            // to a max of 4MB pieces.
            return 4 * 1024 * 1024;
        }

        /// <summary>
        /// Removes a custom value from the main bencoded dictionary.
        /// </summary>
        public void RemoveCustom(BEncodedString key)
        {
            this.dict.Remove(key);
        }

        #endregion Methods


        private void AsyncCreate()
        {
            try
            {
                result.Dictionary = Create();
            }
            catch (Exception ex)
            {
                result.SavedException = ex;
            }

            result.IsCompleted = true;
            result.AsyncWaitHandle.Set();

            if (result.Callback != null)
                result.Callback(this.result);
        }

        ///<summary>
        ///this adds stuff common to single and multi file torrents
        ///</summary>
        private void AddCommonStuff(BEncodedDictionary torrent)
        {
            if (announces.Count > 0 && announces[0].Count > 0)
                torrent.Add("announce", new BEncodedString(announces[0][0]));

            // If there is more than one tier or the first tier has more than 1 tracker
            if (announces.Count > 1 || (announces.Count > 0 && announces[0].Count > 1))
            {
                BEncodedList announceList = new BEncodedList();
                for (int i = 0; i < this.announces.Count; i++)
                {
                    BEncodedList tier = new BEncodedList();
                    for (int j = 0; j < this.announces[i].Count; j++)
                        tier.Add(new BEncodedString(this.announces[i][j]));

                    announceList.Add(tier);
                }

                torrent.Add("announce-list", announceList);
            }


            DateTime epocheStart = new DateTime(1970, 1, 1);
            TimeSpan span = DateTime.Now - epocheStart;
            Logger.Log(null, "creation date: {0} - {1} = {2}:{3}", DateTime.Now, epocheStart, span, span.TotalSeconds);
            torrent.Add("creation date", new BEncodedNumber((long)span.TotalSeconds));
        }

        ///<summary>calculate md5sum of a file</summary>
        ///<param name="fileName">the file to sum with md5</param>
        private void AddMD5(BEncodedDictionary dict, string fileName)
        {
            MD5 hasher = MD5.Create();
            StringBuilder sb = new StringBuilder();

            using (FileStream stream = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.Read))
            {
                byte[] hash = hasher.ComputeHash(stream);

                foreach (byte b in hash)
                {
                    string hex = b.ToString("X");
                    hex = hex.Length > 1 ? hex : "0" + hex;
                    sb.Append(hex);
                }
                Logger.Log(null, "Sum for: '{0}' = {1}", fileName, sb.ToString());
            }
            dict.Add("md5sum", new BEncodedString(sb.ToString()));
        }

        ///<summary>
        ///calculates all hashes over the files which should be included in the torrent
        ///</summmary>
        private byte[] CalcPiecesHash(string path, TorrentFile[] files, PieceWriter writer)
        {
            byte[] piecesBuffer = new byte[GetPieceCount(files) * 20]; //holds all the pieces hashes
            int piecesBufferOffset = 0;
            
            long totalLength = Toolbox.Accumulate<TorrentFile>(files, delegate(TorrentFile f) { return f.Length; });
            ArraySegment<byte> buffer = new ArraySegment<byte>(new byte[PieceLength]);

            while (totalLength > 0)
            {
                int bytesToRead = (int)Math.Min(totalLength, PieceLength);
                BufferedIO io = new BufferedIO(null, buffer, (piecesBufferOffset / 20) * PieceLength, bytesToRead, bytesToRead, files, path);
                totalLength -= writer.ReadChunk(io);

                // If we are using the synchronous version, result is null
                if (result != null && result.Aborted)
                    return piecesBuffer;

                byte[] currentHash = hasher.ComputeHash(buffer.Array, 0, io.ActualCount);
                RaiseHashed(new TorrentCreatorEventArgs(0, 0,//reader.CurrentFile.Position, reader.CurrentFile.Length,
                                                        piecesBufferOffset * PieceLength, (piecesBuffer.Length - 20) * PieceLength));
                Buffer.BlockCopy(currentHash, 0, piecesBuffer, piecesBufferOffset, currentHash.Length);
                piecesBufferOffset += currentHash.Length;
            }
            return piecesBuffer;
        }

        ///<summary>
        ///used for creating multi file mode torrents.
        ///</summary>
        ///<returns>the dictionary representing which is stored in the torrent file</returns>
        protected void CreateMultiFileTorrent(BEncodedDictionary dictionary, TorrentFile[] files, PieceWriter writer, string name)
        {
            AddCommonStuff(dictionary);
            BEncodedDictionary info = (BEncodedDictionary)dictionary["info"];

            BEncodedList torrentFiles = new BEncodedList();//the dict which hold the file infos

            for (int i = 0; i < files.Length; i++)
                torrentFiles.Add(GetFileInfoDict(files[i]));

            info.Add("files", torrentFiles);

            Logger.Log(null, "Topmost directory: {0}", name);
            info.Add("name", new BEncodedString(name));

            info.Add("pieces", new BEncodedString(CalcPiecesHash(Path, files, writer)));
        }

        ///<summary>
        ///used for creating a single file torrent file
        ///<summary>
        ///<returns>the dictionary representing which is stored in the torrent file</returns>
        protected void CreateSingleFileTorrent(BEncodedDictionary dictionary, TorrentFile[] files, PieceWriter writer, string name)
        {
            AddCommonStuff(dictionary);

            BEncodedDictionary infoDict = (BEncodedDictionary)dictionary["info"];
            infoDict.Add("length", new BEncodedNumber(files[0].Length));
            infoDict.Add("name", (BEncodedString)name);

            if (StoreMD5)
                AddMD5(infoDict, Path);

            Logger.Log(null, "name == {0}", name);
            string path = System.IO.Path.GetDirectoryName (Path);
            infoDict.Add("pieces", new BEncodedString(CalcPiecesHash(path, files, writer)));
        }

        private static BEncodedValue Get(BEncodedDictionary dictionary, BEncodedString key)
        {
            return dictionary.ContainsKey(key) ? dictionary[key] : null;
        }

        private void GetAllFilePaths(string directory, MonoTorrentCollection<string> paths)
        {
            string[] subs = Directory.GetDirectories(directory);
            foreach (string path in subs)
            {
                if (ignoreHiddenFiles)
                {
                    DirectoryInfo info = new DirectoryInfo(path);
                    if ((info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden)
                        continue;
                }

                GetAllFilePaths(path, paths);
            }

            subs = Directory.GetFiles(directory);
            foreach (string path in subs)
            {
                if (ignoreHiddenFiles)
                {
                    FileInfo info = new FileInfo(path);
                    if ((info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden)
                        continue;
                }

                paths.Add(path);
            }
        }

        ///<summary>
        ///this method is used for multi file mode torrents to return a dictionary with
        ///file relevant informations. 
        ///<param name="file">the file to report the informations for</param>
        ///<param name="basePath">used to subtract the absolut path information</param>
        ///</summary>
        private BEncodedDictionary GetFileInfoDict(TorrentFile file)
        {
            BEncodedDictionary fileDict = new BEncodedDictionary();

            fileDict.Add("length", new BEncodedNumber(file.Length));

#warning Implement this again
            //if (StoreMD5)
                //AddMD5(fileDict, file);

            Logger.Log(null, "Without base: {0}", file.Path);
            BEncodedList filePath = new BEncodedList();
            string[] splittetPath = file.Path.Split(System.IO.Path.DirectorySeparatorChar);

            foreach (string s in splittetPath)
            {
                if (s.Length > 0)//exclude empties
                    filePath.Add(new BEncodedString(s));
            }

            fileDict.Add("path", filePath);

            return fileDict;
        }

        private long GetPieceCount(TorrentFile[] files)
        {
            long size = 0;
            foreach (TorrentFile file in files)
                size += file.Length;

            //double count = (double)size/PieceLength;
            long pieceCount = size / PieceLength + (((size % PieceLength) != 0) ? 1 : 0);
            Logger.Log(null, "Piece Count: {0}", pieceCount);
            return pieceCount;
        }

        private void RaiseHashed(TorrentCreatorEventArgs e)
        {
            Toolbox.RaiseAsyncEvent<TorrentCreatorEventArgs>(Hashed, this, e);
        }

        private static void Set(BEncodedDictionary dictionary, BEncodedString key, BEncodedValue value)
        {
            if (dictionary.ContainsKey(key))
                dictionary[key] = value;
            else
                dictionary.Add(key, value);
        }
    }
}
